fixing hetziner.mdw

⏺ All actions complete. Final state:

  ┌───────────┬──────────────┬──────────────────────────────────────────────┐
  │  Server   │     Task     │                    Result                    │
  ├───────────┼──────────────┼──────────────────────────────────────────────┤
  │           │ Removed      │ n8n_data, traefik_data removed (were         │
  │ hostinger │ stale docker │ orphaned since Dec 2025, no containers)      │
  │           │  volumes     │                                              │
  ├───────────┼──────────────┼──────────────────────────────────────────────┤
  │           │ Disabled     │ passwordauthentication yes → no (fixed in    │
  │ contabo2  │ password     │ /etc/ssh/ssh_config + 50-cloud-init.conf);   │
  │           │ auth         │ ssh reloaded; service active                 │
  ├───────────┼──────────────┼──────────────────────────────────────────────┤
  │           │              │ permitrootlogin without-password,            │
  │ contabo2  │ Verified     │ pubkeyauthentication yes,                    │
  │           │              │ passwordauthentication no                    │
  └───────────┴──────────────┴──────────────────────────────────────────────┘

  ---
  Hetzner fix — step-by-step guide

  The audit showed two coupled issues:
  1. UFW not installed (no managed firewall)
  2. Ports 5000 (python3) + 8000 (uvicorn) listening on 0.0.0.0 — direct host
  processes (no docker-proxy in the users: field), with manual iptables rules
  allowing docker bridges 172.17.0.0/16 + 172.18.0.0/16 to reach them.

  Current INPUT DROP policy + explicit ACCEPT rules already block the public,
  but the rules are unmanaged. The fix below makes them managed and removes the
  public-listen.

  Phase 1 — Install UFW (safe, idempotent)

  apt update && apt install -y ufw

  # Defaults + public ports
  ufw default deny incoming
  ufw default allow outgoing
  ufw allow 22/tcp
  ufw allow 80/tcp
  ufw allow 443/tcp

  # Preserve current docker-bridge access to the two backend ports
  # (these replace the manual iptables ACCEPT rules you have today)
  ufw allow from 172.17.0.0/16 to any port 5000 comment 'tts-docker-bridge'
  ufw allow from 172.17.0.0/16 to any port 8000 comment 'analysis-docker-bridge'
  ufw allow from 172.18.0.0/16 to any port 5000 comment 'tts-ielts-network'
  ufw allow from 172.18.0.0/16 to any port 8000 comment 'analysis-ielts-network'

  ufw --force enable
  ufw status verbose

  ⚠️  Before enabling: confirm in a second SSH session that you can still log in
  (UFW resets existing iptables; your manual rules will be replaced by the
  equivalent UFW rules above).

  Phase 2 — Identify what's binding 5000/8000

  # Who's listening?
  ss -tlnp | grep -E ':(5000|8000)\b'

  # Trace the process tree (pid 1015 = uvicorn, 1023 = python3 from audit)
  ps -ef | grep -E '(1015|1023|uvicorn|5000|8000)' | grep -v grep
  ls -l /proc/1015/cwd /proc/1023/cwd     # working dir = service root
  cat  /proc/1015/cmdline | tr '\0' ' '   # full uvicorn command line
  cat  /proc/1023/cmdline | tr '\0' ' '

  # Likely managed by systemd
  systemctl list-units --type=service --state=running | grep -iE
  '(ielts|analysis|tts|uvicorn)'

  Phase 3 — Bind to 127.0.0.1 (kills the public listen)

  If systemd unit (most likely):
  systemctl cat <unit-name> | grep ExecStart
  # Edit the unit
  systemctl edit --full <unit-name>
  #   change:  --host 0.0.0.0  →  --host 127.0.0.1
  systemctl daemon-reload && systemctl restart <unit-name>

  If raw uvicorn / flask / fastapi:
  # uvicorn: --host 127.0.0.1 (instead of 0.0.0.0)
  # flask:   app.run(host='127.0.0.1', ...)
  # fastapi: same as uvicorn

  Phase 4 — Give docker containers a way to reach 127.0.0.1

  After binding to localhost, containers in analysis-ielts-network /
  tts-ielts-network can't reach the host service anymore. Pick one:

  Option: A. host.docker.internal
  What to do: Add extra_hosts: ["host.docker.internal:host-gateway"] to each
    compose service + change upstream URL to http://host.docker.internal:8000
  When to use: Recommended; cleanest
  ────────────────────────────────────────
  Option: B. Host network mode
  What to do: Add network_mode: host to consuming container
  When to use: Quick hack; loses network isolation
  ────────────────────────────────────────
  Option: C. Docker bridge IP
  What to do: Bind service to 172.18.0.1 (docker bridge gateway) instead of
    127.0.0.1
  When to use: Keeps docker access, still not on 0.0.0.0

  Phase 5 — Verify

  # Should now show 127.0.0.1:5000 and 127.0.0.1:8000 only
  ss -tlnp | grep -E ':(5000|8000)\b'

  # From outside (run from your laptop):
  nc -zv 46.62.228.173 5000    # should be: connection refused / timeout
  nc -zv 46.62.228.173 8000    # should be: connection refused / timeout

  # Caddy still proxies correctly:
  curl -sI https://<your-ielts-domain>/health    # 200 expected

  Gotchas

  - Docker bypasses UFW for published ports (-p 0.0.0.0:5000:5000). If these
  services are actually docker-published (re-check ss output for docker-proxy in
   the users field), the fix is to change the compose ports: block to
  "127.0.0.1:5000:5000", not Phase 2/3 above.
  - UFW enable wipes manual iptables rules — that's why Phase 1 re-adds the
  docker-bridge allows before enabling.
  - Caddy also needs to reach the backends — Caddy runs on host, so
  127.0.0.1:5000/8000 works for it directly (no host.docker.internal needed for
  Caddy).

  Run Phase 1 (install UFW) first — that's the safe part. Phase 2–4 need you to
  find the service definition; ping me with the output of ps -ef | grep -E
  '(1015|1023)' and I'll point to the exact file to edit.
  ========================================================
  ⏺ Now I have a complete picture. Key findings:

  ┌─────────────────────────────────┬───────────────────────────────────────┐
  │              What               │                 State                 │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │                                 │ Host processes via systemd            │
  │ Services on 5000/8000           │ (ielts-tts.service,                   │
  │                                 │ ielts-analysis.service) — NOT Docker  │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │ Docker published ports          │ All already bound to 127.0.0.1 ✓      │
  │                                 │ (8003, 8094, 3002)                    │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │ Manual iptables rules for       │ STALE — that docker network doesn't   │
  │ analysis-ielts-network          │ exist anymore                         │
  │ (172.18.0.0/16)                 │                                       │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │ Caddy backends                  │ All on 127.0.0.1 (5000, 8000,         │
  │                                 │ 8090-8094, 3005, 8003)                │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │ UFW                             │ Binary missing but /etc/ufw/ config   │
  │                                 │ dir exists + ufw chains in iptables   │
  ├─────────────────────────────────┼───────────────────────────────────────┤
  │ Fail2ban                        │ Active (caddy-4xx + sshd jails)       │
  └─────────────────────────────────┴───────────────────────────────────────┘

  Revised safer plan — do the binding fix first (zero firewall risk), then
  install UFW:

  1. Bind 8000 to 127.0.0.1 via systemd unit edit (uvicorn --host)
  2. Bind 5000 to 127.0.0.1 (need to find host in app.py or systemd)
  3. Restart services, verify Caddy still proxies
  4. Install UFW + allow 22/80/443, enable
  5. Verify SSH alias still works (fresh connection)

  Let me read the systemd units and TTS source to find the host binding.
  
⏺ Bash(ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 'bash -s' << 'EOF'
      echo "=== 1. ielts-analysis.service ==="…)
  ⎿  === 1. ielts-analysis.service ===
     # /etc/systemd/system/ielts-analysis.service
     [Unit]
     … +119 lines (ctrl+o to expand)
     
⏺ Perfect — found both binding locations:
  - 8000: --host 0.0.0.0 in /etc/systemd/system/ielts-analysis.service ExecStart
  - 5000: host='0.0.0.0' in /root/ielts-fast/phonetics-server/app.py:505

  Both are simple sed edits. Executing Phase 1 (binding fix only — zero firewall
   changes, zero SSH risk).

⏺ Bash(ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 'bash -s' << 'PHASE1'
      # Phase 1: bind 5000/8000 to 127.0.0.1 (NO firewall changes, NO SSH
      risk)…)
  ⎿  === 1. Backup + edit ielts-analysis.service ===
     After edit:
     ExecStart=/root/ielts-fast/backend/venv/bin/uvicorn main:app --host 12
     … +37 lines (ctrl+o to expand)
     
⏺ Phase 1 perfect — both services now on 127.0.0.1, all backends return 200,
  Caddy + all services healthy, TTS took 23s for model load.
     
  Now Phase 2: install UFW. The next SSH command after ufw enable IS the SSH
  alias test — if UFW blocks port 22, my next call fails.

⏺ Bash(ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 'bash -s' << 'PHASE2'
      set -e…)
  ⎿  === 1. Install UFW (preserve existing /etc/ufw configs) ===
     W: An error occurred during the signature verification. The repository
      is not updated and the previous index files will be used. GPG error:
     … +76 lines (ctrl+o to expand)
     
⏺ Phase 2 done. Notable: UFW install automatically removed iptables-persistent +
   netfilter-persistent (which were loading the manual rules at boot — UFW now
  handles that). Fail2ban chains preserved at INPUT positions 7-8 (before ufw
  chains — correct order).

  Final verification — this SSH command itself proves the alias works:

⏺ Bash(ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 'bash -s' << 'VERIFY'
      echo "=== SSH ALIAS WORKS (you are reading this) ==="…)
  ⎿  === SSH ALIAS WORKS (you are reading this) ===

     === All public listening ports (must be 22/80/443 only) ===
     … +53 lines (ctrl+o to expand)
      
⏺ Bash(echo "=== External port scan (from local Mac) ==="
      for port in 22 80 443 5000 8000 8090 8091 8092 8093 8094 8003 3002 3005;
      do…)
  ⎿  Running in the background (↓ to manage)
     
⏺ All server-side checks pass. Let me get the external port scan result:

⏺ Task Output bguxmabh8
  ⎿  === External port scan (from local Mac) ===
       port    22: Connection to 46.62.228.173 port 22 [tcp/ssh] succeeded!
     OPEN
     … +8 lines (ctrl+o to expand)

⏺ Task Output bguxmabh8
  ⎿  === External port scan (from local Mac) ===
       port    22: Connection to 46.62.228.173 port 22 [tcp/ssh] succeeded!
     OPEN
     … +9 lines (ctrl+o to expand)

⏺ Task Output bguxmabh8
  ⎿  === External port scan (from local Mac) ===
       port    22: Connection to 46.62.228.173 port 22 [tcp/ssh] succeeded!
     OPEN
     … +11 lines (ctrl+o to expand)

⏺ Stop Task
  ⎿  echo "=== External port scan (from local Mac) ==="
     for port in 22 80 443 5000 8000 8090 8091 8092 8093 8094 8003 3002
     3005; do… · stopped

⏺ All done. Final state — verified working end-to-end.

  Summary
  
  ┌───────────────────────────────────┬─────────────────────────────────────┐
  │               Check               │               Result                │
  ├───────────────────────────────────┼─────────────────────────────────────┤
  │ SSH alias (ssh -i                 │ ✅ works (proven by this command    │
  │ ~/.ssh/hetzner_dokploy            │ running)                            │
  │ root@46.62.228.173)           │                                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Public listening ports on     │ ✅ exactly 22 / 80 / 443                │
  │ hetzner                       │                                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ External port scan (from my   │ ✅ 22/80/443 OPEN;                      │
  │ Mac)                          │ 5000/8000/8090/8091/8092+               │
  │                               │ closed/filtered                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ UFW                           │ ✅ active, enabled at boot, 22/80/443   │
  │                               │ allowed                                 │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Fail2ban                      │ ✅ active (caddy-4xx + sshd jails; 5    │
  │                               │ IPs currently banned)                   │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Chain order in INPUT          │ ✅ f2b-caddy-4xx → f2b-sshd → ufw-*     │
  │                               │ (bans before allows)                    │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │                               │ ✅ caddy, ielts-analysis, ielts-tts,    │
  │ All app services              │ ielts-pocketbase, docker, ssh, ufw,     │
  │                               │ fail2ban = all active                   │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │                               │ ✅ ielts.fast=200, promedic1.com=200,   │
  │ Caddy backends                │ /api/health=200, /api/quick-check=422   │
  │                               │ (correct)                               │
  └───────────────────────────────┴─────────────────────────────────────────┘

  What was changed

  Phase 1 — Bind services to localhost (zero SSH/firewall risk):
  - /etc/systemd/system/ielts-analysis.service: --host 0.0.0.0 → --host 
  127.0.0.1
  - /root/ielts-fast/phonetics-server/app.py:505: host='0.0.0.0' →
  host='127.0.0.1'
  - Backups: *.bak.20260702_* alongside originals
  - Restarted: ielts-analysis (immediate), ielts-tts (23s for XTTS model load)

  Phase 2 — UFW install:
  - apt install ufw (auto-removed obsolete iptables-persistent +
  netfilter-persistent which were preserving the stale 172.18 network rules at
  boot)
  - Rules: 22/tcp + 80/tcp + 443/tcp, default deny incoming, allow outgoing
  - Existing /etc/ufw/* configs preserved via --force-confold
  
  Rollback path (if ever needed)

  # Restore service bindings to 0.0.0.0
  ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 '
    latest_unit=$(ls -t /etc/systemd/system/ielts-analysis.service.bak.* | head
  -1)
    latest_app=$(ls -t /root/ielts-fast/phonetics-server/app.py.bak.* | head -1)
    cp "$latest_unit" /etc/systemd/system/ielts-analysis.service
    cp "$latest_app"  /root/ielts-fast/phonetics-server/app.py
    systemctl daemon-reload
    systemctl restart ielts-analysis ielts-tts
    ufw disable
  '

  Side-effect notes (so you know, no action needed)

  - The stale manual rules in INPUT (positions 1-6, referencing
  analysis-ielts-network 172.18.0.0/16 which doesn't exist anymore) are still in
   iptables but are harmless dead code — kernel can't route to 127.0.0.1-bound
  sockets from 172.x anyway. They'll disappear on next reboot since
  iptables-persistent is gone and nothing re-adds them.
  - Docker published ports (webapp_ielts:8003, the-ielts:8094, uptime-kuma:3002)
   were already on 127.0.0.1 — no changes needed there.
  - XTTS model load on TTS restart: 23s downtime. If you restart ielts-tts in
  the future, expect ~20-30s when port 5000 is unresponsive. Caddy will return
  502 during that window.
  ============================================================
  
  Summary
  
  ┌───────────────────────────────────┬─────────────────────────────────────┐
  │               Check               │               Result                │
  ├───────────────────────────────────┼─────────────────────────────────────┤
  │ SSH alias (ssh -i                 │ ✅ works (proven by this command    │
  │ ~/.ssh/hetzner_dokploy            │ running)                            │
  │ root@46.62.228.173)           │                                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Public listening ports on     │ ✅ exactly 22 / 80 / 443                │
  │ hetzner                       │                                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ External port scan (from my   │ ✅ 22/80/443 OPEN;                      │
  │ Mac)                          │ 5000/8000/8090/8091/8092+               │
  │                               │ closed/filtered                         │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ UFW                           │ ✅ active, enabled at boot, 22/80/443   │
  │                               │ allowed                                 │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Fail2ban                      │ ✅ active (caddy-4xx + sshd jails; 5    │
  │                               │ IPs currently banned)                   │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │ Chain order in INPUT          │ ✅ f2b-caddy-4xx → f2b-sshd → ufw-*     │
  │                               │ (bans before allows)                    │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │                               │ ✅ caddy, ielts-analysis, ielts-tts,    │
  │ All app services              │ ielts-pocketbase, docker, ssh, ufw,     │
  │                               │ fail2ban = all active                   │
  ├───────────────────────────────┼─────────────────────────────────────────┤
  │                               │ ✅ ielts.fast=200, promedic1.com=200,   │
  │ Caddy backends                │ /api/health=200, /api/quick-check=422   │
  │                               │ (correct)                               │
  └───────────────────────────────┴─────────────────────────────────────────┘

  What was changed

  Phase 1 — Bind services to localhost (zero SSH/firewall risk):
  - /etc/systemd/system/ielts-analysis.service: --host 0.0.0.0 → --host 
  127.0.0.1
  - /root/ielts-fast/phonetics-server/app.py:505: host='0.0.0.0' →
  host='127.0.0.1'
  - Backups: *.bak.20260702_* alongside originals
  - Restarted: ielts-analysis (immediate), ielts-tts (23s for XTTS model load)

  Phase 2 — UFW install:
  - apt install ufw (auto-removed obsolete iptables-persistent +
  netfilter-persistent which were preserving the stale 172.18 network rules at
  boot)
  - Rules: 22/tcp + 80/tcp + 443/tcp, default deny incoming, allow outgoing
  - Existing /etc/ufw/* configs preserved via --force-confold
  
  Rollback path (if ever needed)

  # Restore service bindings to 0.0.0.0
  ssh -i ~/.ssh/hetzner_dokploy root@46.62.228.173 '
    latest_unit=$(ls -t /etc/systemd/system/ielts-analysis.service.bak.* | head
  -1)
    latest_app=$(ls -t /root/ielts-fast/phonetics-server/app.py.bak.* | head -1)
    cp "$latest_unit" /etc/systemd/system/ielts-analysis.service
    cp "$latest_app"  /root/ielts-fast/phonetics-server/app.py
    systemctl daemon-reload
    systemctl restart ielts-analysis ielts-tts
    ufw disable
  '

  Side-effect notes (so you know, no action needed)

  - The stale manual rules in INPUT (positions 1-6, referencing
  analysis-ielts-network 172.18.0.0/16 which doesn't exist anymore) are still in
   iptables but are harmless dead code — kernel can't route to 127.0.0.1-bound
  sockets from 172.x anyway. They'll disappear on next reboot since
  iptables-persistent is gone and nothing re-adds them.
  - Docker published ports (webapp_ielts:8003, the-ielts:8094, uptime-kuma:3002)
   were already on 127.0.0.1 — no changes needed there.
  - XTTS model load on TTS restart: 23s downtime. If you restart ielts-tts in
  the future, expect ~20-30s when port 5000 is unresponsive. Caddy will return
  502 during that window.

✻ Cooked for 14m 43s
  
※ recap: Hardening 4 servers; hostinger and ai-developer are done (UFW + 
  fail2ban + key-only SSH), contabo2 password auth is disabled, hetzner just had
   UFW installed and ports 5000/8000 bound to localhost. Next: tell me if you 
  want to fix anything else.
  
❯ what should I do now                                                          

⏺ Quick honest answer:
  
  1. Watch for breakage (next 24h). The binding + UFW changes just landed on
  hetzner. Things to spot-check:
  - Hit https://ielts.fast/api/health and https://ielts.fast/api/quick-check
  from a real device — confirm 200 / expected response
  - tail /var/log/caddy/ielts.fast.log on hetzner — no 502 spikes
  - fail2ban-client status sshd on hetzner — bans still trending normally
  (currently 5, was 4792 total) 
  - Rollback is one command (in my previous message) if anything's broken
  
  2. Then pick the next item from the backlog (issues 7 + 8 you said leave —
  these are the rest):

  ┌───────────────────────────────┬──────────┬────────────────────┬────────┐
  │             Issue             │  Server  │      Severity      │ Effort │
  ├───────────────────────────────┼──────────┼────────────────────┼────────┤
  │ Outline hardcoded secrets     │ contabo2 │ High (real risk if │ ~15    │
  │                               │          │  box escapes)      │ min    │
  ├───────────────────────────────┼──────────┼────────────────────┼────────┤
  │ Caddy 2.10.2 (2.10.x has      │ hetzner  │ Medium             │ ~10    │
  │ CVE-2025-6119 + 3 others)     │          │                    │ min    │
  ├───────────────────────────────┼──────────┼────────────────────┼────────┤
  │ X11Forwarding yes             │ all 4    │ Low                │ ~2 min │
  ├───────────────────────────────┼──────────┼────────────────────┼────────┤
  │ Hetzner containers running    │ hetzner  │ (you said leave)   │ —      │
  │ without compose               │          │                    │        │
  ├───────────────────────────────┼──────────┼────────────────────┼────────┤
  │ Duplicated marketing cron on  │ all 4    │ (you said leave)   │ —      │
  │ all 4                         │          │                    │        │
  └───────────────────────────────┴──────────┴────────────────────┴────────┘

  Recommendation: do the Caddy upgrade + X11Forwarding off (cheap, mechanical).
  Investigate the Outline secrets one separately since it's the only thing left
  with real attack surface. Say which one (or "stop here") and I'll act.